[ENG-8064] Add New Notifications Data Model (Refactor Notifications Phase 2)#11151
Conversation
3a8b414 to
57b0bd1
Compare
2b8ba0d to
c449599
Compare
ad18e9d to
300524c
Compare
2dee1b2 to
2dbcbf7
Compare
9238258 to
37b419a
Compare
afc3815 to
b6bbeed
Compare
1a4314d to
2894a24
Compare
…/fix/reviews_signals [ENG-8851][ENG-8852] Preprint Moderator Reject: No emails sent when moderator rejects preprint submission
…/fix/registration_fail_notification [ENG-8871] Registrations: No notifications sent when registration fails.
…otifications-send-when-account-merged [ENG-8869] Fix/make send email on account merge
| _check_resource_permissions(resource, auth, action) | ||
|
|
||
| provider_name = waterbutler_data['provider'] | ||
| waterbutler_settings = None |
…/fix/file_event_notifications [ENG-8861] File Operations: Email content and subject not correct when file is renamed
…otification-send-when-primary-email-changed [ENG-8872] Fix/no notification send when primary email changed
…/fix/submission_notifications [ENG-8848] Preprint pending submission notifications: Content of email is not relevant when new preprint is submitted to OSFPreprints
| ) | ||
|
|
||
| def remove_user_from_subscription(self, user): | ||
| """ |
There was a problem hiding this comment.
Change docstring
| f"\nemail_context={email_context}" | ||
|
|
||
| ) | ||
| if self.message_frequency == 'instantly': |
There was a problem hiding this comment.
Maybe use enum or not litteral string.
| Node/etc: <guid>_<event> | ||
| """ | ||
| # Safety checks | ||
| event = self.notification_type.name |
There was a problem hiding this comment.
File updates are the only event after comment removal.
cslzchen
left a comment
There was a problem hiding this comment.
Progress update: 152/389 (last file is osf/models/notification.py)
- On blocker I see is we modify old migrations again, which I suggest make new migrations. This is something that delayed phase 1.
In addition to my comments:
-
I see a bunch of management commands removed? Some are unrelated with notification refactor. Have you double checked that they are no longer used? They may have usage outside admin app.
-
I see you have 3-weeks old comments for yourself but many of them never addressed. Can your provide an update on your own comments?
| Guid, | ||
| FileVersionUserMetadata, | ||
| FileVersion | ||
| FileVersion, NotificationType |
| if payload.get('email'): | ||
| notification_type = NotificationType.Type.USER_FILE_OPERATION_SUCCESS.instance | ||
| if payload.get('errors'): | ||
| notification_type = NotificationType.Type.USER_FILE_OPERATION_FAILED.instance |
There was a problem hiding this comment.
I may not understand the details yet but should we check 'errors' first?
i.e. when we (if we can) have both 'emails' and 'errors', checking 'emails first ignores the 'errors'
In addition, should we raise exceptions if neither exists
|
|
||
| logger.info('Successfully uploaded query output to OSF.') | ||
| logger.debug('Task ends <<<<<<<<') | ||
| await sync_to_async(send_mail)( |
There was a problem hiding this comment.
As discussed, I couldn't remember why but I am wondering why we have to change it. Does keeping it async break notification refactor?
| # create AdminProfile for this new user | ||
| profile, created = AdminProfile.objects.get_or_create(user=osf_user) | ||
|
|
||
| for group in form.cleaned_data.get('group_perms'): |
There was a problem hiding this comment.
Is this "on demand" thing part of the new refactor design? Or this is an old code clean up unrelated to refactor?
| context = super().get_context_data(**kwargs) | ||
| node = self.get_object() | ||
|
|
||
| detailed_duplicates = detect_duplicate_notifications(node_id=node.id) |
There was a problem hiding this comment.
I assume this is part of the refactor design too?
| @cached_property | ||
| def instance(self): | ||
| obj, created = NotificationType.objects.get_or_create(name=self.value) | ||
| return obj |
There was a problem hiding this comment.
As discussed, we don't have any implementation to clear the cache unless. What is the case where we need to clear cache? What could go wrong if we don't? At least I'd like to see this discussed and documented for post-release work.
| reviews_comments_anonymous = models.BooleanField(null=True, blank=True) | ||
|
|
||
| DEFAULT_SUBSCRIPTIONS = ['new_pending_submissions'] | ||
| DEFAULT_SUBSCRIPTIONS = [ |
There was a problem hiding this comment.
What's the issue here, has this comment been figured out?
| # remove notification subscription | ||
| for subscription in self.DEFAULT_SUBSCRIPTIONS: | ||
| self.remove_user_from_subscription(user, f'{self._id}_{subscription}') | ||
| self.remove_user_from_subscription(user, subscription) |
| self.add_permission(contrib.user, permission, save=True) | ||
| Contributor.objects.bulk_create(contribs) | ||
|
|
||
| def subscribe_contributors_to_node(self): |
There was a problem hiding this comment.
So how is this done after the refactor? Or maybe this is just dead code?
There was a problem hiding this comment.
Never used even on develop
fix file renamed email fix email digest fix digest formating
opaduchak
left a comment
There was a problem hiding this comment.
Progress: 100%
I've mostly dedicated my attention to main code, loooking more shallow on tests and templates.
My only main concern is: Some test classes and signal handlers are entirely removed, even through they are not directly related to notifications refactor.
It's hard to track if or whether they have been moved in PR of this scale
|
|
||
| logger.info('Successfully uploaded query output to OSF.') | ||
| logger.debug('Task ends <<<<<<<<') | ||
| await sync_to_async(send_mail)( |
There was a problem hiding this comment.
I don't think so, but on the other hand async_to_sync is quite expensive operation (takes nearly 1ms according to django docs)
My opinion is that we need to refactor this entire method to get rid of async, as the only time it is being used is inside of celery task, so we have none of the benefits of async, only the drawbacks
| context['provider__id'] = provider._id | ||
| context['is_reviews_moderator_notification'] = True | ||
| context['referrer_fullname'] = user.fullname | ||
| context['user_fullname'] = user.fullname |
There was a problem hiding this comment.
Nit: this has already been set on L353
| f_type, action = self.action.split('_') | ||
| if self.payload['metadata']['materialized'].endswith('/'): | ||
| f_type = 'folder' | ||
| return '{action} {f_type} "<b>{name}</b>".'.format( | ||
| action=markupsafe.escape(action), | ||
| f_type=markupsafe.escape(f_type), | ||
| name=markupsafe.escape(self.payload['metadata']['materialized'].lstrip('/')) | ||
| ) |
There was a problem hiding this comment.
Should not this be inside of mako template?
There was a problem hiding this comment.
Do you mean the markupsafe for the whole return string? It's probably doesn't need to escape twice, but that's the way this works, these events are compiled as a list of html form matted string within the template.
| @register(NodeLog.FILE_REMOVED) | ||
| class FileRemoved(FileEvent): | ||
| pass | ||
|
|
||
|
|
||
| @register(NodeLog.FOLDER_CREATED) | ||
| class FolderCreated(FileEvent): | ||
| pass |
There was a problem hiding this comment.
I believe they are inherited from FileEvent
| except (SGUnauthorizedError, SGForbiddenError) as exc: | ||
| body = getattr(exc, 'body', b'') | ||
| try: | ||
| body = body.decode('utf-8', 'ignore') if isinstance(body, (bytes, bytearray)) else str(body) | ||
| except Exception: | ||
| pass | ||
| logging.error('SendGrid auth error (%s): %s', exc.__class__.__name__, body) | ||
| raise | ||
|
|
||
| except SGHTTPError as exc: | ||
| body = getattr(exc, 'body', b'') | ||
| try: | ||
| body = body.decode('utf-8', 'ignore') if isinstance(body, (bytes, bytearray)) else str(body) | ||
| except Exception: | ||
| pass | ||
| logging.error('SendGrid HTTPError: %s | payload=%s', body, payload) | ||
| raise |
There was a problem hiding this comment.
this may be deduplicated
There was a problem hiding this comment.
Be more specific somethings may appear duplicated but the exact wording points to the exact cause of error.
There was a problem hiding this comment.
Something like this, just noticed a lot of repetition here
| except (SGUnauthorizedError, SGForbiddenError) as exc: | |
| body = getattr(exc, 'body', b'') | |
| try: | |
| body = body.decode('utf-8', 'ignore') if isinstance(body, (bytes, bytearray)) else str(body) | |
| except Exception: | |
| pass | |
| logging.error('SendGrid auth error (%s): %s', exc.__class__.__name__, body) | |
| raise | |
| except SGHTTPError as exc: | |
| body = getattr(exc, 'body', b'') | |
| try: | |
| body = body.decode('utf-8', 'ignore') if isinstance(body, (bytes, bytearray)) else str(body) | |
| except Exception: | |
| pass | |
| logging.error('SendGrid HTTPError: %s | payload=%s', body, payload) | |
| raise | |
| except (SGUnauthorizedError, SGForbiddenError, SGHTTPError) as exc: | |
| body = getattr(exc, 'body', b'') | |
| try: | |
| body = body.decode('utf-8', 'ignore') if isinstance(body, (bytes, bytearray)) else str(body) | |
| except Exception: | |
| pass | |
| if isinstance(exc, SGHTTPError): | |
| logging.error('SendGrid auth error (%s): %s', exc.__class__.__name__, body) | |
| else: | |
| logging.error('SendGrid HTTPError: %s | payload=%s', body, payload) | |
| raise |
| REGISTRATION_BULK_UPLOAD_FAILURE_DUPLICATES = 'registration_bulk_upload_failure_duplicates' | ||
|
|
||
| DRAFT_REGISTRATION_CONTRIBUTOR_ADDED_DEFAULT = 'draft_registration_contributor_added_default' | ||
|
|
There was a problem hiding this comment.
Can't we add to simplify things?
| def emit( | |
| self, | |
| user=None, | |
| destination_address=None, | |
| subscribed_object=None, | |
| message_frequency='instantly', | |
| event_context=None, | |
| email_context=None, | |
| is_digest=False, | |
| save=True, | |
| ): | |
| self.instance.emit(user, destination_address, subscribed_object, message_frequency, event_context, email_context, is_digest, save) |
There was a problem hiding this comment.
I am a bit confused by this, I think you mean a classmethod? or staticmethod instead?
There was a problem hiding this comment.
no, this should be ordinary method, using the same logic as the instance property, replacing
NotificationType.Type.DRAFT_REGISTRATION_CONTRIBUTOR_ADDED_DEFAULT.instance.emit
with
NotificationType.Type.DRAFT_REGISTRATION_CONTRIBUTOR_ADDED_DEFAULT.emit
There was a problem hiding this comment.
NotificationType.Type.DRAFT_REGISTRATION_CONTRIBUTOR_ADDED_DEFAULT still an enum member, at that point so NotificationType.Type.DRAFT_REGISTRATION_CONTRIBUTOR_ADDED_DEFAULT.emit wouldn't work. But I can see with you're other comments that the I can achieve that with those changes too.
There was a problem hiding this comment.
it will work if we add emit method to the enum, the same way as instance property works
|
|
||
| class NotificationType(models.Model): | ||
|
|
||
| class Type(str, Enum): |
There was a problem hiding this comment.
Do we really need to make this nested enum? I'd rather make it full-fledged enum, which will result in drastivally simpler api:
from this:
NotificationType.Type.REVIEWS_SUBMISSION_STATUS.instance.emit()
to this (NotificationTypes is our full-fledged enum):
NotificationTypes.REVIEWS_SUBMISSION_STATUS.emit()
There was a problem hiding this comment.
@cslzchen I think that's a much better idea, but involves a big diff, so I'm saving this for last in case there's more important things.
There was a problem hiding this comment.
I agree but this will be a large change. Will this involve migration too? Will QA needs to retest it?
There was a problem hiding this comment.
No migration just moving the Enum so it's not nested, it is more of a stylistic thing. if you look at how def instance is constructed, you can see how to improve like @opaduchak suggests, it would be just a textual change, but only to avoid the nested class and having a Type attribute. The Type enum is just the list of notification type names, they all have unique names, so it maps to the enum.
| @@ -1,15 +1,20 @@ | |||
| from django.contrib import admin, messages | |||
There was a problem hiding this comment.
Should not this file be in admin package?
There was a problem hiding this comment.
It's a bit confusing because we have the admin package which is custom HTML at admin.osf.io and the "admin admin" which is automatically generated UI. That's this file. This defines the "admin admin" which is found at admin.osf.io/admin, not admin.osf.io hence the name two admins.
There was a problem hiding this comment.
Understood, excuse me, I mistook this for newly created file
| @run_postcommit(once_per_request=False, celery=True) | ||
| @app.task(max_retries=5, default_retry_delay=60) | ||
| def remove_supplemental_node_from_preprints(node_id): | ||
| AbstractNode = apps.get_model('osf.AbstractNode') | ||
|
|
||
| node = AbstractNode.load(node_id) | ||
| for preprint in node.preprints.all(): | ||
| if preprint.node is not None: | ||
| preprint.node = None | ||
| preprint.save() |
There was a problem hiding this comment.
Why are we having this deleted? It doesn't seem to be related to notifications
There was a problem hiding this comment.
Most of that file was no longer necessary, but that one function was still needed, so I moved it to notifications/listeners.py because as you will see it's basically a wrapper for a listener
…tions-data-model Conflicts will be fixed in next commit: * api/users/views.py * api_tests/users/views/test_user_settings.py
Purpose
This system is designed to formalize and consolidate OSF Notifications under one system in order to better facilitate collaboration between Product, Engineers and QA and end persistent problems with notification email related tech debt. This project is the result of an extensive audit of all OSF emails and combined email digests and seeks to move the
NotificationSubscriptionmodel out of it's transitional state having been migrated from a document based model, into it's final data model which is more regular for a relation our current relational database (django's postgress db).In order to do this I have taken @mfraezz design documents and implemented them with minor changes. This means the responsibility for sending notifications in osf.io is shared by three new models, which will have the old data migrated into them via a migration script and management command. The models are:
NotificationTypenotification.yamla developer will be able to see at a glance if where a notification template is and what it's purpose is.NotificationSubscriptionEmailDigestmodel, this holds references to potentially many Notifcations models that can be compiled into a single digest this is sent at a periodic basis.NotificationChanges
notifications.yamlto contain all notification types info it is populated via postmigrate hookNotificationNotificationSubscriptionandNotificationTypesend_mailsmethod and replaces it withemitofNotificationTypehandle_boa_errorto pass the already decanted user object.notifications.yamlfor data dependency notificationtypescapture_notificationsmocking utilqueued_mailEmailDigestdetect_duplicatesfor duplicate subscriptionsQA Notes
Please make verification statements inspired by your code and what your code touches.
What are the areas of risk?
Any concerns/considerations/questions that development raised?
Documentation
Side Effects
Ticket
https://openscience.atlassian.net/browse/ENG-8064